Skip to content

S04-03 集合框架-泛型

[TOC]

泛型

泛型(Generics)JDK 5 引入的一个重大特性。它的本质是参数化类型(Parameterized Types),也就是说,将原本固定的具体数据类型变成一个“参数”,在使用时再传入具体的类型。

为什么需要泛型

没有泛型的痛点

在泛型出现之前(Java 5 之前),为了让一个容器(如 ArrayList)能存储任何对象,内部只能使用 Java 的所有类之父:Object。但这带来了两个致命痛点:

  1. 类型转换繁琐:每次从容器中取数据,都必须进行强制类型转换。

  2. 运行时潜在崩溃风险:编译器无法在编译时检查类型是否匹配。如果往里面误存了不同类型的对象,代码在编译时不会报错,但一运行就会抛出 ClassCastException

引入泛型后的对比

  • 没有泛型(老代码):

    java
    List list = new ArrayList();
    list.add("Hello");
    list.add(123); // 编译不报错,什么都能放
    
    String str = (String) list.get(0); // 必须强转
    String str2 = (String) list.get(1); // ❌ 运行时抛出 ClassCastException 异常!
  • 有了泛型(新代码):

    java
    List<String> list = new ArrayList<>();
    list.add("Hello");
    // list.add(123); // ❌ 编译直接报错!提前规避风险
    
    String str = list.get(0); // 自动类型检查,无需强转

总结泛型的好处

  • 类型安全:将运行时的类型检查提前到了编译期。
  • 消除强制类型转换:代码更简洁、可读性更高。
  • 提高代码复用性:一套逻辑可以适用多种数据类型。

基本语法

泛型可以应用在接口方法上。在语法上,通常用尖括号 < > 包裹一个或多个类型参数。

泛型类

泛型类就是在类名后面添加了类型参数。这个参数在类的内部可以作为成员变量的类型、方法的参数类型或返回值类型。

语法结构:

java
public class 类名<T> {
  private T data;
  // ...
}

代码示例:

java
// 定义一个通用的盒子类,可以装任何类型的物品
public class Box<T> {
  private T item;

  public void setItem(T item) {
    this.item = item;
  }

  public T getItem() {
    return item;
  }
}

// 使用泛型类
public class Main {
  public static void main(String[] args) {
    // 创建一个装 String 的盒子
    Box<String> stringBox = new Box<>();
    stringBox.setItem("魔法棒");
    String item = stringBox.getItem(); // 自动就是 String 类型

    // 创建一个装 Integer 的盒子
    Box<Integer> intBox = new Box<>();
    intBox.setItem(2026);
  }
}

泛型接口

泛型接口的定义与泛型类相似。实现该接口的类有两种选择:要么在实现时指定具体类型,要么让实现类也继续保持泛型。

代码示例:

java
// 定义泛型接口
public interface Generator<T> {
  T next();
}

// 情况 A:实现类在定义时直接指定具体类型(例如 String)
public class StringGenerator implements Generator<String> {
  @Override
  public String next() {
    return "Hello World";
  }
}

// 情况 B:实现类不指定具体类型,继续延续泛型
public class GenericGenerator<T> implements Generator<T> {
  private T data;

  @Override
  public T next() {
    return data;
  }
}

泛型方法

泛型方法是指方法本身带有类型参数。需要特别注意的是:泛型方法是否是泛型,与它所在的类是否是泛型类无关。 也就是说,普通类里也可以定义泛型方法。

语法结构:

注意:必须在修饰符(如 public static)和返回值类型之间加上 <T>

java
public static <T> void printArray(T[] array) { ... }

代码示例:

java
public class MethodDemo {
  // 这是一个标准的泛型方法
  public static <E> void printArray(E[] inputArray) {
    for (E element : inputArray) {
      System.out.printf("%s ", element);
    }
    System.out.println();
  }

  public static void main(String[] args) {
    // 测试不同的数组类型
    Integer[] intArray = {1, 2, 3};
    String[] stringArray = {"A", "B", "C"};

    // 调用泛型方法,编译器会自动推导类型
    printArray(intArray);    // E 被推导为 Integer
    printArray(stringArray); // E 被推导为 String
  }
}

泛型占位符

在编写泛型代码时,你经常会看到 T, E, K, V 等字母。它们在语法上没有本质区别,换成任何字母都可以正常运行,但在业内有一套约定俗成的含义,能提高代码的可读性:

占位符英文原意常见使用场景
TType(类型)泛型类或普通泛型参数的最常用代称
EElement(元素)广泛用于 Java 集合框架中(如 List<E>, Set<E>
KKey(键)键值对映射中的“键”(如 Map<K, V>
VValue(值)键值对映射中的“值”(如 Map<K, V>
NNumber(数字)通常用于限定只能是数值类型

通配符

Java 泛型是不型变(Invariant)的。简单来说,虽然 DogAnimal 的子类,但 List<Dog> 并不是 List<Animal> 的子类。这就导致我们无法把 List<Dog> 传给一个接收 List<Animal> 的方法。

为了解决这种因“类型安全”而带来的灵活性限制,Java 引入了通配符(Wildcards),也就是著名的问号 ?

Java 共有三种通配符语法:无界通配符上限通配符下限通配符。下面为你逐一拆解。

无界通配符:<?>

无界通配符表示“任意未知类型”。当你只关心元素的操作,而不关心元素的具体类型时,就可以使用它。

  • 语法: Class<?>
  • 特性: 只能读,不能写(除了 null
java
public static void printList(List<?> list) {
  for (Object elem : list) {
    System.out.print(elem + " "); // 1. 只能用 Object 接收
  }
  System.out.println();

  // list.add("hello"); // ❌ 2. 编译报错!因为不知道具体类型,写入是不安全的
  list.add(null);       // 3. 只有 null 是特例,因为 null 是引用类型都有的值
}

适用场景: 方法的实现只依赖于 Object 类提供的方法(如 toString()),或者只依赖于容器自身的方法(如 size(), clear())。

上限通配符:<? extends T>

上限通配符用来限制类型的最高边界。它表示该类型可以是 T 本身,或者是 T 的任何子类

  • 语法: <? extends T>
  • 特性(关键): 只能安全地读取(Get),不能写入(Set)

为什么不能写

假设有以下继承关系:Fruit -> Apple -> RedApple

java
List<? extends Fruit> fruits = new ArrayList<Apple>();

// ❌ 编译报错!
// 编译器只知道 fruits 里面装的是 Fruit 的某种子类,但不知道具体是 Apple 还是 Orange
// 如果允许你 add 一个 Orange() 进去,那原本的 ArrayList<Apple> 就被污染了!
fruits.add(new Apple());

为什么能读

因为无论底层真正实例化的是什么(Apple 还是 Orange),它们必然都是 Fruit。所以你拿出来的东西,百分之百可以用 Fruit 来接收。

java
public static double sumOfList(List<? extends Number> list) { // list 遍历出的元素是 Number 的某个子类
  double s = 0.0;
  for (Number n : list) { // ✅ 安全读取,Upcasting 向上转型
    s += n.doubleValue();
  }
  return s;
}

下限通配符:<? super T>

下限通配符用来限制类型的最低边界。它表示该类型可以是 T 本身,或者是 T 的任何父类(一路直到 Object)。

  • 语法: <? super T>
  • 特性(关键): 可以安全地写入(Set) T 及其子类,但读取(Get)出来的只能是 Object

为什么能写

因为编译器知道这个容器的底线是 T。往一个“装 TT 的父类”的容器里放入一个 T 对象(或 T 的子类对象),由于多态性,这绝对是安全的。

为什么不能读

因为边界是“向上”的,最高可能是 Object。编译器无法预知你读出来的到底是 FruitFood 还是 Object,所以为了绝对安全,读出来的类型只能是 Object

java
public static void addNumbers(List<? super Integer> list) {
  list.add(1);  // ✅ 1. 安全写入 Integer
  list.add(2);  // ✅ 2. 安全写入 Integer 的子类(如果有的话)

  // Integer num = list.get(0); // ❌ 3. 编译报错!读出来的是 Object
  Object obj = list.get(0);    // ✅ 4. 只有这样写才行
}

PECS 原则

通配符的使用经常让人头晕。对此,Java 领域有一个著名的指导原则:PECS(Producer Extends, Consumer Super)

  • Producer Extends(生产者用 extends): 如果你的数据结构主要是为了输出(生产)数据给外部使用(即只读不写),它就像一个“生产者”,此时应该使用 <? extends T>
  • Consumer Super(消费者用 super): 如果你的数据结构主要是为了接收外部输入的(消费)数据并存储(即只写不读),它就像一个“消费者”,此时应该使用 <? super T>

经典案例:Collections.copy()

Java 源码中的 copy 方法完美阐述了这一原则:

java
public static <T> void copy(List<? super T> dest, List<? extends T> src) {
  for (int i = 0; i < src.size(); i++) {
    dest.set(i, src.get(i));
  }
}
  • src 是数据源,负责提供(Produce)数据,所以用 extends
  • dest 是目标容器,负责消费(Consume)并存入数据,所以用 super

总结与对比表格

通配符类型语法含义能否读取 (Get)能否写入 (Set)典型应用 (PECS)
无界通配符<?>任意未知类型只能读出 Object不能写(除 null独立于类型的通用操作
上限通配符<? extends T>TT 的子类,读出为 T不能(除 nullProducer(只读数据源)
下限通配符<? super T>TT 的父类只能读出 Object,可写 T 及其子类Consumer(只写容器)

集合与比较器中的泛型

Java 泛型最日常、最核心的战场,莫过于 Java 集合框架(Collections Framework)对象比较机制(Comparable / Comparator)。在这两个领域,泛型彻底终结了过去不断强转的混乱时代。

下面我们深入探讨泛型在这两大场景下的具体应用与高级进阶(尤其是配合通配符的高级玩法)。

集合框架中的应用

Java 集合框架中的几乎所有接口和类都是泛型化的(如 List<E>, Set<E>, Map<K,V>)。这里的 E 代表 Element(元素),KV 代表 Key 和 Value。


应用1:集合的声明与实例化

从 Java 7 开始,引入了钻石操作符(Diamond Operator) <>,我们在右侧的构造函数中不需要再重复写一遍类型,编译器会自动进行类型推导。

java
// List 集合:单列集合,允许重复
List<String> names = new ArrayList<>();

// Map 集合:双列集合(键值对)
Map<Integer, String> userMap = new HashMap<>();

应用2:集合的遍历

在没有泛型前,使用 Iteratorfor-each 遍历集合极其痛苦,因为取出来的都是 Object。有了泛型后,遍历变得优雅而安全。

java
List<String> list = Arrays.asList("Java", "Python", "Go");

// 显式指定类型,直接使用 String 的方法,无需强转
for (String lang : list) {
  System.out.println(lang.toUpperCase());
}

image-20260606174151557

比较器中的应用

Java 中实现对象排序主要依赖两个泛型接口:Comparable<T>(内部比较器)和 Comparator<T>(外部比较器)。


应用1:Comparable<T> 接口

让一个类实现 Comparable<T> 接口,意味着这个类本身具备了与其他同类对象比较的能力

java
// 约束当前类只能与 Student 类型的对象进行比较
public class Student implements Comparable<Student> {
  private String name;
  private int age;

  public Student(String name, int age) {
    this.name = name;
    this.age = age;
  }

  @Override
  public int compareTo(Student other) {
    // 年龄升序排序
    return Integer.compare(this.age, other.age);
  }
}

应用2:Comparator<T> 接口

如果你无法修改某个类的源码(比如第三方库的类),或者一个类需要多种排序策略(按年龄排、按成绩排),就需要使用 Comparator<T>

java
import java.util.Comparator;

// 创建一个专门按姓名排序的比较器
public class NameComparator implements Comparator<Student> {
  @Override
  public int compare(Student s1, Student s2) {
    return s1.getName().compareTo(s2.getName());
  }
}

现代 Java 玩法(Lambda):在实际开发中,我们很少专门写一个比较器类,通常配合泛型使用 Lambda 表达式或方法引用:

Comparator<Student> ageComp = (s1, s2) -> Integer.compare(s1.getAge(), s2.getAge());

或者是:Comparator<Student> ageComp = Comparator.comparingInt(Student::getAge);

通配符的使用

在 Java 的 Collections 工具类中,有一个非常著名的排序方法 sort,它的源码签名定义如下:

java
public static <T> void sort(List<T> list, Comparator<? super T> c)

这里为什么是 Comparator<? super T>,而不是 Comparator<T>?这就是我们上一篇提到的 PECS 原则 中的 Consumer Super。比较器 c 在这里是消费(使用) T 元素的,所以用 super

为什么这样做能带来极大的灵活性

假设有以下继承关系:Animal(父类) \rightarrow Dog(子类)。

由于 Dog 继承自 AnimalDog 天然拥有 Animal 的所有属性(比如 age)。现在我们有一个通用的动物年龄比较器:

java
// 一个可以比较任何动物年龄的比较器
Comparator<Animal> animalAgeComparator = (a1, a2) -> Integer.compare(a1.getAge(), a2.getAge());

如果我们想对一个装满小狗的集合进行排序:

java
List<Dog> dogs = new ArrayList<>();
dogs.add(new Dog("哈士奇", 3));
dogs.add(new Dog("柯基", 1));

// 这里的 T 是 Dog。
// Comparator<? super Dog> 意味着可以接受 Comparator<Dog>,也可以接受 Comparator<Animal>。
Collections.sort(dogs, animalAgeComparator);
  • 如果签名是 Comparator<T>:那么 Collections.sort(dogs, ...)就只能接收Comparator<Dog>,上面的 animalAgeComparator就会编译报错。这就逼着你为Dog 重新写一个一模一样的比较器,造成代码冗余。
  • 因为签名是 Comparator<? super T>:父类的比较器可以直接复用到子类集合的排序中。这完美体现了面向对象的多态性与泛型结合的威力。

实战:泛型的应用

下面的代码完整展示了泛型集合、Lambda 比较器以及利用 Comparator 进行多级排序的优雅写法。

java
import java.util.*;

public class CollectionGenericDemo {
  public static void main(String[] args) {
    // 1. 声明泛型集合
    List<Student> students = new ArrayList<>();
    students.add(new Student("Tom", 22));
    students.add(new Student("Jerry", 20));
    students.add(new Student("Alice", 20));

    // 2. 使用具有泛型推导的 Comparator 进行复合排序:先按年龄排,年龄相同按姓名排
    students.sort(
      Comparator.comparingInt(Student::getAge)
            .thenComparing(Student::getName)
    );

    // 3. 打印结果
    students.forEach(s -> System.out.println(s.getName() + ": " + s.getAge()));
  }
}

集合与比较器是 Java 泛型最成功的实践。掌握了 ? extends T(只读数据源)和 ? super T(只写/消费容器,如 Comparator)的逻辑,你在阅读 Spring、MyBatis 或是 JDK 源码中复杂的集合操作时,就再也不会感到吃力了。

继承中的泛型

在 Java 中,将泛型与面向对象的继承(Inheritance)结合起来时,极易产生直觉上的误区。理解泛型在继承中的行为,是编写健壮框架和容器类代码的关键。

下面为你详细梳理 Java 泛型在继承中的四大核心规则、子类继承父类的常见姿势,以及如何利用通配符恢复继承关系。

泛型不型变

List<Dog> 不是 List<Animal> 的子类

这是初学者最容易踩的坑。在 Java 中,如果 DogAnimal 的子类,我们直觉上会认为 List<Dog> 也是 List<Animal> 的子类。但事实完全相反:它们之间没有任何继承关系。

为什么?

假设 Java 允许这种继承关系,那么以下代码将合法,但会导致严重的运行时崩溃:

java
List<Dog> dogs = new ArrayList<>();
List<Animal> animals = dogs; // 1. 假设这行能编译通过

// 2. animals 和 dogs 指向同一个内存地址
animals.add(new Cat()); // ❌ 完蛋了!把一只猫放进了狗窝里

Dog dog = dogs.get(0); // ❌ 运行时抛出 ClassCastException,因为拿到的是猫!

为了在编译期杜绝这种安全隐患,Java 规定:无论两个泛型参数之间有什么继承关系,它们的泛型组合(如 List<A>List<B>)在编译期都是平辈关系,互不兼容。

真正的泛型继承轴线

虽然 List<String>List<Object> 没有继承关系,但泛型类/接口本身的继承链依然有效,前提是泛型参数必须保持一致

正确的继承关系示例

  • ArrayList<String> List<String> 的子类。

  • List<String> Collection<String> 的子类。

  • HashSet<Integer> Set<Integer> 的子类。

    java
    // 正确:类型参数一致,容器类本身存在继承关系
    List<String> list = new ArrayList<String>();
    Collection<Integer> col = new HashSet<>();
    
    // 错误:虽然 Object 是 String 的父类,但整体不构成继承
    // ArrayList<Object> list2 = new ArrayList<String>();

子类继承泛型父类方式

当你自己定义一个类,去继承一个泛型父类(或实现泛型接口)时,Java 提供了三种处理泛型参数的方式。我们以父类 Father<T> 为例:

java
public class Father<T> {
  private T info;
  public void setInfo(T info) { this.info = info; }
  public T getInfo() { return info; }
}
方式1:擦除父类泛型

如果不给父类传任何类型,父类的 T 会被擦除为 Object。这种做法失去了泛型的意义,属于老旧代码的写法。

java
// 子类变成普通类,父类中的 T 变成 Object
public class ChildA extends Father {
  // 此时重写的方法变成了:
  // @Override public void setInfo(Object info) { ... }
}
方式2:子类定死父类泛型

在继承时,直接给父类传入一个具体的类型。此时,子类变成了普通类。

java
// 子类明确了父类处理的是 String
public class ChildB extends Father<String> {
  // 此时重写的方法自动变为具体类型:
  @Override
  public void setInfo(String info) { super.setInfo(info); }
}

// 使用时:
ChildB cb = new ChildB();
cb.setInfo("具体字符串"); // 只能传 String
方式3:子类延续泛型

子类自己也定义成泛型类,并将自己的泛型参数(或其中之一)传递给父类

java
// 子类也是泛型类,把自己的 T 传给父类
public class ChildC<T> extends Father<T> {
  // 依然保持泛型特性
}
java
// 甚至子类可以扩展更多的泛型参数
public class ChildD<T, E> extends Father<T> {
  private E extraData; // 子类独有的新泛型参数
}

通配符恢复继承

如果你确实面临一种业务场景:需要写一个方法,既能接收 List<Dog>,又能接收 List<Animal>。既然直接的继承行不通,这时候就轮到~~通配符(Wildcards)~~出场了。

我们可以通过上限/下限通配符,在泛型之间架起一座“伪继承”的桥梁:

extends 向上继承
java
// List<? extends Animal> 成了 List<Dog> 和 List<Animal> 的共同父类!
List<? extends Animal> list1 = new ArrayList<Dog>();
List<? extends Animal> list2 = new ArrayList<Animal>();

原理: ? extends Animal 表示“某种继承自 Animal 的未知子类”。因为 Dog 满足这个条件,所以 List<Dog> 可以安全地向上赋值给它。

super 向下继承
java
// List<? super Dog> 成了 List<Dog> 和 List<Animal> 的共同父类!
List<? super Dog> list3 = new ArrayList<Dog>();
List<? super Dog> list4 = new ArrayList<Animal>();

原理: ? super Dog 表示“某种是 Dog 父类的未知类型”。因为 Animal 满足这个条件,所以 List<Animal> 也可以安全地赋值给它。

泛型继承判断口诀

在开发中,当你需要判断两个泛型表达式是否具有继承/赋值关系时,请按以下顺序检查:

  1. 看外层容器(类/接口):如果外层容器没有继承关系(例如 ListSet),那它们绝对不能互相赋值。

  2. 看括号内的类型

    • 如果都是确切类型(如 <String><Object>),必须完全一致才能赋值。
    • 如果包含通配符(如 <? extends Animal>),则根据通配符的上限或下限,判断右侧的类型是否落在左侧的“射程范围”内。

类型擦除

类型擦除(Type Erasure)是 Java 泛型实现的核心机制。简单来说:Java 的泛型只存在于编译期,在进入运行期(JVM)之后,所有的泛型信息都会被消灭。

这就是为什么人们常说 Java 的泛型是“伪泛型”。下面为你彻底拆解类型擦除的工作原理、背后的设计初衷以及它带来的副作用。

工作流程

在编译期间,Java 编译器(javac)会检查你的泛型代码是否安全,一旦检查通过,它就会进行“擦除”操作。主要做三件事:

步骤1:替换类型参数

编译器会把所有的泛型参数(如 T, E)替换为它们的原始类型(Raw Type)

  • 如果泛型没有指定上限(如 <T>),则替换为 Object
  • 如果指定了上限(如 <T extends Number>),则替换为上限类型 Number

编译前(你写的代码):

java
public class Holder<T> {
  private T value;
  public T getValue() { return value; }
  public void setValue(T value) { this.value = value; }
}

编译后(JVM 实际看到的字节码):

java
public class Holder {
  private Object value; // T 被擦除为 Object
  public Object getValue() { return value; }
  public void setValue(Object value) { this.value = value; }
}

步骤2:自动插入强制类型转换

既然 JVM 内部全是 Object,那为什么我们写代码时不需要强转呢?因为编译器在调用泛型方法的地方,自动帮我们加上了强转代码。

编译前:

java
Holder<String> holder = new Holder<>();
holder.setValue("Hello");
String str = holder.getValue(); // 无需强转

编译后:

java
Holder holder = new Holder();
holder.setValue("Hello");
String str = (String) holder.getValue(); // 编译器自动插入了 (String) 强转

步骤3:生成桥接方法

为了保证泛型在继承或实现接口时的多态性,编译器有时不得不秘密生成一个“桥接方法”。

假设有一个泛型接口和它的实现类:

java
public interface Node<T> {
  void setData(T data);
}

public class MyNode implements Node<Integer> {
  @Override
  public void setData(Integer data) { ... }
}

类型擦除后,Node 接口的方法变成了 setData(Object data)。如果 MyNode 只有 setData(Integer),那就无法实现接口的动态绑定了。

于是,编译器会偷偷在 MyNode 中生成一个桥接方法

java
// 编译器自动生成的桥接方法
public void setData(Object data) {
  this.setData((Integer) data); // 调用你写的具体方法
}

类型擦除采用原因

Java 在 2004 年(Java 5)引入泛型时,面临一个巨大的历史包袱:如何让新写的泛型代码和过去 10 年写的老代码(无泛型)无缝兼容?

C++ 采用的方法是“模板实现”(为每种类型复制一份新代码),这会导致“代码膨胀”,且新老代码无法兼容。

Java 最终选择了二进制兼容性(Binary Compatibility)。通过类型擦除,Java 5 的泛型集合类(如 ArrayList<T>)编译后的字节码,和 Java 1.4 的老集合类(如 ArrayList)几乎一模一样。老系统的 .class 文件不需要重新编译,就能直接在新的 JVM 上运行。

类型擦除验证方法

如果你想在代码中亲自验证类型擦除,可以用以下两个经典方法:

实验1:运行时类检查

java
Class c1 = new ArrayList<String>().getClass();
Class c2 = new ArrayList<Integer>().getClass();

System.out.println(c1 == c2); // 输出:true

结论:在运行时,ArrayList<String>ArrayList<Integer> 的二进制类对象是完全同一个,泛型标签消失了。

实验2:利用反射绕过泛型检查

因为泛型约束只在编译期有效,运行期被擦除,所以我们可以通过反射往一个 List<String> 里面塞进一个整数:

java
List<String> list = new ArrayList<>();
list.add("Java");

// 利用反射获取运行时的 add 方法(此时参数已经是 Object 了)
Method method = list.getClass().getMethod("add", Object.class);
method.invoke(list, 2026); // 成功塞入一个 Integer!

System.out.println(list); // 输出:[Java, 2026]

类型擦除副作用

天下没有免费的午餐,为了兼容性而选择的类型擦除,给 Java 留下了诸多的限制(也就是大名鼎鼎的“泛型坑”):

  • 无法使用基本数据类型:不能写 List<int>。因为擦除后会变成 Object,而 int 不是对象。必须使用包装类 List<Integer>

  • 无法使用 instanceof 关键字

    java
    // 编译报错!JVM 在运行时根本不知道什么是 List<String>
    if (obj instanceof List<String>) { }
  • 无法直接实例化泛型

    java
    // 编译报错!因为擦除后变成了 new Object(),失去了原本的设计意图
    T item = new T();
  • 泛型数组不合法

    java
    // 编译报错!Java 禁止创建确切的泛型类型数组
    List<String>[] lists = new ArrayList<String>[10];
  • 方法重载冲突:下面这两个方法在编译后方法签名一模一样(都是 List),因此编译器拒绝编译:

    java
    public void print(List<String> list) {}
    public void print(List<Integer> list) {} // 编译报错:Method signature conflicts

练习题

习题1:泛型操作类 DAO

image-20260609153455437

习题2:泛型类 User

image-20260609154413623

image-20260609154548796

image-20260609154236473

习题3:泛型方法

image-20260609155228425

习题4:泛型方法

image-20260609155433951

习题5:泛型类 Student

image-20260609155659673

image-20260609160054791

实战:DAO【